# MadTalk Godot Plugin by Fernando Cosentino
# https://github.com/fbcosentino/godot-madtalk
#
# License: MIT
# (But if you can be so kind as to mention the original in your Readme in case
# you base any work on this, I would be very glad :] )

extends Node


signal dialog_acknowledged
signal text_display_completed
signal speaker_changed(previous_speaker_id, previous_speaker_variant, new_speaker_id, new_speaker_variant)
signal voice_clip_requested(speaker_id, clip_path)

signal dialog_started(sheet_name, sequence_id)
signal dialog_finished(sheet_name, sequence_id)

#warning-ignore:unused_signal
signal evaluate_custom_condition(custom_id, custom_data)
#warning-ignore:unused_signal
signal activate_custom_effect(custom_id, custom_data)

signal dialog_sequence_processed(sheet_name, sequence_id)
signal dialog_item_processed(sheet_name, sequence_id, item_index)

signal message_text_shown(speaker_id, speaker_variant, message_text, force_hiding)

signal menu_option_activated(option_id)
signal time_updated(datetime_dict)

# Requests the menu to be processed externally, if dialog_buttons_container is not set
# menu_options is an Array of DialogNodeOptionData
# The signal handler can process it as below:
#    func _on_MadTalk_external_menu_requested(menu_options):
#        for option in menu_options:
#            print(option.text) # Prints the string for this option as written in the sheet
# One of the options can then be selected with:
#    $MadTalk.select_menu_option( <numerical index in the menu_options array> )
signal external_menu_requested(menu_options)

signal dialog_aborted

# Your scene should have a Control-descendant node with all dialog controls
# inside. The top Control should be overlayed on top of all your visual elements
# so it can capture mouse events first. One way to acomplish this is to
# simply have it at the end of your scene tree child list, with "Full Rect" 
# layout and the mouse filter set to "Stop". Your scene root node can be of any
# type (doesn't have to descend from Control). It can even be a Spatial in
# a normal 3D project

## Array containing the character data, one record per character
## All items in this array must be of type MTCharacterData
@export var ListOfCharacters: Array[MTCharacterData] = [] # (Array, Resource)

@export_group("Layout Nodes")
## This is the main control overlay used to show all dialog activity under
## MadTalk responsibility. Usually a Control with "Full Rect" layout and mouse
## filter set to "Stop", but other scenarios are possible at your discretion.
@export var DialogMainControl: NodePath
@onready var dialog_maincontrol: Control = get_node_or_null(DialogMainControl)

## The Control-descendant holding all the objects in the text box
## but not the menu. Menu must be able to become visible when this is hidden
## In most simple cases this can be the label itself (or a Panel holding it)
@export var DialogMessageBox: NodePath
@onready var dialog_messagebox: Control = get_node_or_null(DialogMessageBox)

## The RichTextLabel used to display dialog messages
@export var DialogMessageLabel: NodePath
@onready var dialog_messagelabel: Control = get_node_or_null(DialogMessageLabel)

@export_subgroup("Speaker")

## The Label or RichTextLabel used to display the speaker name
@export var DialogSpeakerLabel: NodePath
@onready var dialog_speakerlabel = get_node_or_null(DialogSpeakerLabel)

## The TextureRect for showing avatars
@export var DialogSpeakerAvatar: NodePath
@onready var dialog_speakeravatar = get_node_or_null(DialogSpeakerAvatar)

@export_subgroup("Menu")

## The Control-descendant holding the entire button menu, including containers,
## decorations, etc. Hiding this should be enough to leave no trace of the
## menu on screen
## Having a menu in the game is entirely optional and you can leave menu-related
## items unassigned if you don't use menus in the dialog system
@export var DialogButtonsMenu: NodePath
@onready var dialog_menu = get_node_or_null(DialogButtonsMenu)

## The container (usually VBoxContainer) which will hold the button instances
## directly. There must be nothing inside this node, this is the lowest
## hierarchy node in the customization/decoration branch of the scene tree, and
## buttons will be created as direct children of this node
## If this node is not assigned, menus can still be used externally via signals
## If this is not assigned and menu is also not handled externally, menu options
## will not work
@export var DialogButtonsContainer: NodePath
@onready var dialog_buttons_container = get_node_or_null(DialogButtonsContainer)

@export_subgroup("Menu/Custom Button for Menu")
## (Optional) The PackedScene file containing a button template used to build the menu.
## You do not need this set to use menus. MadTalk will use the default Button
## class if this field is left blank.
## If assigned, must have a signal without ambiguity for direct connection which is emitted
## only when the option is selected. Signal must have no arguments.
## Many actions sharing a same signal having different values for an argument
## (e.g. InputEvent) is not supported.
@export var DialogButtonSceneFile: PackedScene = null

## If DialogButtonSceneFile is assigned: name of property used to set the text when instancing the node
## (otherwise, leave as is)
@export var DialogButtonTextProperty: String = "text"

## If DialogButtonSceneFile is assigned: Signal name emitted when the option is confirmed
## (otherwise, leave as is)
@export var DialogButtonSignalName: String = "pressed"

@export_subgroup("")

@export_group("Animations")

@export_subgroup("Custom Fade-in Fade-out Animations")

## AnimationPlayer object used for fade-in and fade-out transition animations
## if not given, animations will simply be disabled and only show() and hide() 
## will be used instead
@export var DialogAnimationPlayer: NodePath
@onready var dialog_anims = get_node_or_null(DialogAnimationPlayer)

# Below are animation names taken from the AnimationPlayer specified above.
# Make sure the fade out animations have valid information in the last frame
# If a track ends before the last frame, duplicate the last keyframe at (or
# after) the last frame. Applying the updates of only the last frame must be 
# enough to reset the tracks to their faded-out states



## (If DialogAnimationPlayer is assigned:)
## Animation for dialog fade in - displays the DialogMainControl node entirely
@export var TransitionAnimationName_DialogFadeIn: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for dialog fade out - hides the DialogMainControl node entirely
@export var TransitionAnimationName_DialogFadeOut: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for message box fade in - displays the DialogMessageBox node
@export var TransitionAnimationName_MessageBoxFadeIn: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for message box fade out - hides the DialogMessageBox node
@export var TransitionAnimationName_MessageBoxFadeOut: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for menu fade in - displays the DialogButtonsMenu node entirely
@export var TransitionAnimationName_MenuFadeIn: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for menu fade out - hides the DialogButtonsMenu node entirely
@export var TransitionAnimationName_MenuFadeOut: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for message showing up - e.g. characters gradually being typed
@export var TransitionAnimationName_TextShow: String = ""
## (If DialogAnimationPlayer is assigned:)
## Animation for message disappearing
@export var TransitionAnimationName_TextHide: String = ""

@export_subgroup("Progressive Text")


## Automatically animate text tweening the Label's percent_visible property
@export var AnimateText: bool = true
@export var AnimatedTextMilisecondPerCharacter: float = 50.0

## The AudioStreamPlayer containing the sound effect to play when text is being
## shown (example, clicks, key press, beep, etc). If the sound should play
## repeatedly until text is complete (most common case), set the audio stream
## to loop in its import options, otherwise it will play only once.
@export var KeyPressAudioStreamPlayer: NodePath
@onready var sfx_key_press = get_node_or_null(KeyPressAudioStreamPlayer)

@export_subgroup("Animations Called From Effects")


## AnimationPlayer used to play effect animations, when using the effect "Play Animation and Wait"
@export var EffectsAnimationPlayer: NodePath
@onready var effects_anims = get_node_or_null(EffectsAnimationPlayer)

@export_subgroup("")

@export_group("Advanced/Message Formatting")

## Text to be inserted before every message (of every speaker). This will be inserted
## BEFORE any formatting, so you can use all available syntax in it, including BBCode
## and variable parsing, 
## e.g.: [b][lb]b[rb][lb]color=yellow[rb]$npc[lb]/color[rb][lb]/b[rb]: [/b]
@export var TextPrefixForAllMessages := ""

## Text to be appended after every message (of every speaker). Similarly to 
## Text Prefix For All Messages, this is appended BEFORE formatting, so all syntax
## is available. If you have BBCode tags left open in the prefix, you should close
## them here.
@export var TextSuffixForAllMessages := ""

@export_group("Options")


## When a sequence ends in a message item, and the sequence has options for a
## menu, after showing the message the user has to interact acknowledging the
## message before the menu is shown. Enabling this option will automatically
## show the menu with the last message.
@export var AutoShowMenuOnLastMessage := false

@export_subgroup("In-Game Date-Time")

## (Only relevant if you're using MadTalk to handle in-game date and time.)
## Year base is used to offset the calendar, datetime objects are referenced
## to year 0001, and developer can shift that to any year of convenience to
## match dates to weekdays and leap years. Check docs on github to understand
## how to use this. Default works fine.
@export var YearOfReference: int = 1970

@export_subgroup("Debug")

@export var EnableDebugOutput: bool = false

@export_subgroup("")
@export_group("")

# ==============================================================================



class DialogCursor:
	var sheet_name : String = ""
	var sequence_id : int = 0
	var item_index : int = 0
	
	func _init(sheetname, sequenceid, _itemindex):
		sheet_name = sheetname
		sequence_id = sequenceid





# Dialog data - you can customize this if you want, but leaving the default
# should work just fine
var dialog_data = preload("res://addons/madtalk/runtime/madtalk_data.tres")


# For each sheet, the array index for each sequence ID is searched only once
# and the map is cached to avoid unnecessary lookup loops
# This variable holds the map
# Structure is:
# sheet_sequence_to_index = {
#     "sheet_name": {
#         <sequence_ID>: <index in sheet.nodes Array>,
#         ...
#     },
#     ...
# }
var sheet_sequence_to_index = {}

# Dictionary mapping character ID to MTCharacterData
var character_data = {}

# If for some reason a dialog is fired when another one is still going on, the
# new one is added to the queue. Whenever a dialog ends, the queue is checked 
# and fired if required
# Structure is:
# dialog_queue = [<list of DialogCursor items>]
var dialog_queue = []

# Flags tracking the state of dialog Control nodes
# we don't rely on properties (like `visible`) since the user might have a
# different logic to display or hide messages (including resizing UI or
# always-visible elements)
var dialog_maincontrol_active  = false
var dialog_messagebox_active   = false
var dialog_messagelabel_active = false
var dialog_menu_active         = false
var dialog_on_text_progress    = false
var last_speaker_id = "" # used to identify if speaker_id has just changed
var last_speaker_variant = ""
var last_message_item = null
var last_message_text = ""

# Stores Tween node for text animation if used
var animated_text_tween = null

# Holds the target callable to be called when
# evaluating custom conditions
var custom_condition_callable = null

# Holds the target callable to be called when
# activating custom effects
var custom_effect_callable = null

# Flags set when a request to abort or skip the dialog are issued
# The difference between them is: when a dialog is skipped, messages are not
# shown anymore, but all the dialog tree is still traversed, all conditions are
# checked, animations are played and effects take place. Aborting stops where it
# is. This is important since game logic can be critically based on those 
# effects. E.g. if an effect in the end of a conversation spawns a boss,
# skipping the dialog still spanws the boss, while aborting doesn't.
# Both flags are always cleared when starting a dialog.
var is_abort_requested = false
var is_skip_requested = false


# Array mapping menu indices to the dialog IDs they connect to
# Mostly used when using external menus
var menu_connected_ids = []

# RandomNumberGenerator used for, well, random numbers
# Global is not used to avoid restricting from other uses
var rng = RandomNumberGenerator.new()

var msgparser = MessageCodeParser.new()

func debug_print(text: String) -> void:
	if EnableDebugOutput:
		print("MADTALK: "+text)

func bool_as_int(value):
	return 0 if (value == 0) else 1


func _ready():
	var condition_connection_array = get_signal_connection_list("evaluate_custom_condition")
	if condition_connection_array.size() > 0:
		custom_condition_callable = condition_connection_array[0]["callable"]

	var effect_connection_array = get_signal_connection_list("activate_custom_effect")
	if effect_connection_array.size() > 0:
		custom_effect_callable = effect_connection_array[0]["callable"]
	
	MadTalkGlobals.set_game_year(YearOfReference)
	
	rng.randomize()
	
	if (dialog_anims):
		# Sanitizes the animation names ensuring we only have valid animations
		
		if not dialog_anims is AnimationPlayer:
			dialog_anims = null
			
		else:
			if not dialog_anims.has_animation(TransitionAnimationName_DialogFadeIn):
				TransitionAnimationName_DialogFadeIn = ""
			if not dialog_anims.has_animation(TransitionAnimationName_DialogFadeOut):
				TransitionAnimationName_DialogFadeOut = ""
			if not dialog_anims.has_animation(TransitionAnimationName_MenuFadeIn):
				TransitionAnimationName_MenuFadeIn = ""
			if not dialog_anims.has_animation(TransitionAnimationName_MenuFadeOut):
				TransitionAnimationName_MenuFadeOut = ""
			if not dialog_anims.has_animation(TransitionAnimationName_TextShow):
				TransitionAnimationName_TextShow = ""
			if not dialog_anims.has_animation(TransitionAnimationName_TextHide):
				TransitionAnimationName_TextHide = ""
				
			dialog_anims.connect("animation_finished", Callable(self, "_on_animation_finished"))
			
			# Move animations to their respective faded-out states
			# or hide dialog main control and menu
			if TransitionAnimationName_DialogFadeOut != "":
				dialog_anims.play(TransitionAnimationName_DialogFadeOut, -1, 1.0, true)
				dialog_anims.advance(0)
			else:
				dialog_maincontrol.hide()
				
			if TransitionAnimationName_MenuFadeOut != "":
				dialog_anims.play(TransitionAnimationName_MenuFadeOut, -1, 1.0, true)
				dialog_anims.advance(0)
			else:
				if dialog_menu:
					dialog_menu.hide()

			if TransitionAnimationName_TextHide != "":
				dialog_anims.play(TransitionAnimationName_TextHide, -1, 1.0, true)
				dialog_anims.advance(0)
			
	for char_data_item in ListOfCharacters:
		character_data[char_data_item.id] = char_data_item
			
	if (not dialog_data) or (not dialog_data is DialogData):
		# Unfortunately we have an invalid database, discard and make a new one
		dialog_data = DialogData.new()
		debug_print("Dialog data invalid, using a blank one instead")
		
	#if AnimateText:
	#	animated_text_tween = get_tree().create_tween()
	#	#add_child(animated_text_tween)
	#	
	#	animated_text_tween.owner = self
	#	animated_text_tween.connect("tween_all_completed", Callable(self, "_on_animated_text_tween_completed"))

		if dialog_messagelabel:
			dialog_messagelabel.percent_visible = 0
	
	MadTalkGlobals.is_during_dialog = false
	await get_tree().process_frame
	emit_signal("time_updated", MadTalkGlobals.gametime)
		

func _prepare_sheet_sequence_map(sheet_name, sequence_id) -> int:
	# Check if we need to lookup and add this sheet/sequence to map
	# Happens the first time it is accessed
	if not sheet_name in sheet_sequence_to_index:
		sheet_sequence_to_index[sheet_name] = {}
	if not sequence_id in sheet_sequence_to_index[sheet_name]:
		var found = false
		for i in range(dialog_data.sheets[sheet_name].nodes.size()):
			if dialog_data.sheets[sheet_name].nodes[i].sequence_id == sequence_id:
				sheet_sequence_to_index[sheet_name][sequence_id] = i
				found = true
				break
		if not found:
			return FAILED
			
	return OK
	


func _retrieve_sequence_data(sheet_name: String = "", sequence_id: int = 0) -> Resource:
	if not sheet_name in dialog_data.sheets:
		debug_print("Requested sheet \"%s\" which doesn't exist" % sheet_name)
		return null
	var sheet_data = dialog_data.sheets[sheet_name]

	if (not sheet_name in sheet_sequence_to_index) or (not sequence_id in sheet_sequence_to_index[sheet_name]):
		debug_print("Sequence ID %s not mapped in sheet \"%s\" when it should" % [sequence_id, sheet_name])
		return null
	var sequence_index = sheet_sequence_to_index[sheet_name][sequence_id]

	if sequence_index >= sheet_data.nodes.size():
		debug_print("Sequence index %s out of node range in sheet \"%s\" when it should" % [sequence_index, sheet_name])
		return null
	return sheet_data.nodes[sequence_index]

func _retrieve_item_data(sequence_data, item_index: int = 0) -> Resource:
	if not sequence_data:
		return null
		
	if item_index >= sequence_data.items.size():
		return null
	return sequence_data.items[item_index]



func _anim_dialog_main_visible(show: bool = true) -> void:
	if show:
		# Show main dialog interface if not yet visible
		if not dialog_maincontrol_active:
			dialog_maincontrol_active = true
			if TransitionAnimationName_DialogFadeIn != "":
				dialog_anims.play(TransitionAnimationName_DialogFadeIn)
				await dialog_anims.animation_finished
			else:
				if dialog_maincontrol:
					dialog_maincontrol.show()


	
	else:
		# Hide dialog box
		if dialog_maincontrol_active:
			if TransitionAnimationName_DialogFadeOut != "":
				dialog_anims.play(TransitionAnimationName_DialogFadeOut)
				await dialog_anims.animation_finished
			else:
				if dialog_maincontrol:
					dialog_maincontrol.hide()
			
			dialog_maincontrol_active = false


func _anim_dialog_messagebox_visible(show: bool = true) -> void:
	
	if show:
		# Show message box
		if not dialog_messagebox_active:
			dialog_messagebox_active = true
			if TransitionAnimationName_MessageBoxFadeIn != "":
				dialog_anims.play(TransitionAnimationName_MessageBoxFadeIn)
				await dialog_anims.animation_finished
			else:
				if dialog_messagebox:
					dialog_messagebox.show()

	
	else:
		# Hide message box
		if dialog_messagebox_active:
			if TransitionAnimationName_MessageBoxFadeOut != "":
				dialog_anims.play(TransitionAnimationName_MessageBoxFadeOut)
				await dialog_anims.animation_finished
			else:
				if dialog_messagebox:
					dialog_messagebox.hide()
			
			dialog_messagebox_active = false



func _anim_dialog_text_visible(show: bool = true, percent_visible_range: Array= [0.0, 1.0], skip_animation: bool = false) -> void:
	if show:
		# Display animation always plays even if text is already visible
		dialog_messagelabel_active = true
		if AnimateText:
			if TransitionAnimationName_TextShow != "":
				dialog_anims.play(TransitionAnimationName_TextShow)
				# If AnimateText is used, AnimationPlayer is not expected to
				# handle text progression, so we wait the normal way
				await dialog_anims.animation_finished
		
			if not skip_animation:

				if dialog_messagelabel:
					dialog_on_text_progress = true
					dialog_messagelabel.visible_ratio = percent_visible_range[0]
					# Tween text progression
					if animated_text_tween:
						animated_text_tween.kill()
					animated_text_tween = create_tween()
					animated_text_tween.tween_property(dialog_messagelabel, "visible_ratio", percent_visible_range[1], 
						AnimatedTextMilisecondPerCharacter * dialog_messagelabel.text.length() * 0.001
					).set_trans(Tween.TRANS_LINEAR)
					animated_text_tween.tween_callback(_on_animated_text_tween_completed)

					if sfx_key_press:
						sfx_key_press.play()
					await self.text_display_completed # both user interaction or animation_finished are routed here
					if sfx_key_press:
						sfx_key_press.stop()
					dialog_on_text_progress = false
			
			else:
				if dialog_messagelabel:
					dialog_messagelabel.percent_visible = 1.0
				
			
		else:
			if TransitionAnimationName_TextShow != "":
				if not skip_animation:
					dialog_on_text_progress = true
					dialog_anims.play(TransitionAnimationName_TextShow)
					await self.text_display_completed # both user interaction or animation_finished are routed here
					dialog_on_text_progress = false
				else:
					dialog_anims.assigned_animation = TransitionAnimationName_TextShow
					dialog_anims.seek(0)
					dialog_anims.advance(dialog_anims.current_animation_length)
			
		
	else:
		if dialog_messagelabel_active:
			if TransitionAnimationName_TextHide != "":
				dialog_anims.play(TransitionAnimationName_TextHide)
				await dialog_anims.animation_finished
			dialog_messagelabel.visible_ratio = 0
			await get_tree().process_frame
			dialog_messagelabel_active = false


func _anim_dialog_menu_visible(show: bool = true) -> void:
	if show:
		# Menu is always regenerated when shown
		# So animation is also always played
		dialog_menu_active = true
		if TransitionAnimationName_MenuFadeIn != "":
			dialog_anims.play(TransitionAnimationName_MenuFadeIn)
			await dialog_anims.animation_finished
		else:
			if dialog_menu:
				dialog_menu.show()
		
	else:
		if dialog_menu_active:
			if TransitionAnimationName_MenuFadeOut != "":
				dialog_anims.play(TransitionAnimationName_MenuFadeOut)
				await dialog_anims.animation_finished
			else:
				if dialog_menu:
					dialog_menu.hide()
			
			dialog_menu_active = false


func _assemble_button(id: int, text: String, parent_node: Node) -> Node:
	var new_btn = DialogButtonSceneFile.instantiate() if DialogButtonSceneFile else Button.new()
	
	parent_node.add_child(new_btn)
	new_btn.set(DialogButtonTextProperty, text)
	# _on_menu_button_pressed() is used to multiplex all button signals into one
	new_btn.connect(DialogButtonSignalName, Callable(self, "_on_menu_button_pressed").bind(id))
	
	return new_btn
	
	
func _assemble_menu(options: Array) -> int:
	# options = [<list of DialogNodeOptionData>]
	# Fields:
	#     DialogNodeOptionData.text            : String = ""
	#     DialogNodeOptionData.text_locales    : Dictionary (locale String: text String)
	#         read via get_localized_text()
	#     DialogNodeOptionData.connected_to_id : int    = -1

	if not dialog_buttons_container:
		debug_print("Menu button container not set")
		return 0
		
	# Remove any previous buttons
	var old_buttons = dialog_buttons_container.get_children()
	for btn in old_buttons:
		dialog_buttons_container.remove_child(btn)
		btn.queue_free()
		
	# Add new buttons
	var count = 0
	menu_connected_ids.clear()
	for option_item in options:
		var item_text = option_item.get_localized_text() # option_item.text
		menu_connected_ids.append(item_text)
		var _new_btn = _assemble_button(option_item.connected_to_id, item_text, dialog_buttons_container)
		count += 1
		
	return count


func _check_option_condition(var_name: String, operator: String, given_value: String) -> bool:
	var result = false
	var var_value = MadTalkGlobals.get_variable(var_name, 0)
	
	var value = given_value.to_float() if given_value.is_valid_float() else MadTalkGlobals.get_variable(given_value, 0)
	
	match operator:
		"=":
			result = (var_value == value)
		"!=":
			result = (var_value != value)
		">":
			result = (var_value > value)
		">=":
			result = (var_value >= value)
		"<":
			result = (var_value < value)
		"<=":
			result = (var_value <= value)
		_:
			result = false
	
	return result


func set_variable(variable_name: String, value) -> void:
	MadTalkGlobals.set_variable(variable_name, value)


func get_variable(variable_name: String):
	return MadTalkGlobals.get_variable(variable_name)


func start_dialog(sheet_name: String, sequence_id : int = 0) -> void:
	if MadTalkGlobals.is_during_dialog:
		dialog_queue.append( DialogCursor.new(sheet_name, sequence_id, 0) )
		return

	# Start processing dialog. This flag will cause any other calls to be queued
	# Other in-game effects might also read this flag (such as pausing enemies)
	MadTalkGlobals.is_during_dialog = true
	# `yield` statements from now on are safe, even nested into method calls
	# -----------------------------------------------------
	
	is_abort_requested = false
	is_skip_requested = false
	
	emit_signal("dialog_started", sheet_name, sequence_id)
	
	# The "dialog_started" signal can be used to prevent some dialogs by
	# skipping or aborting dialogs before they start
	# This is useful when player repeats a level from a checkpoint, you still
	# need the effects, but not the text, so skip still calls the method below
	if (not is_abort_requested):
		await run_dialog_sequence(sheet_name, sequence_id)

	MadTalkGlobals.is_during_cinematic = true
	
	# Hide menu if needed
	if dialog_menu_active:
		await _anim_dialog_menu_visible(false)

	# Hide text if needed
	if dialog_messagelabel_active:
		await _anim_dialog_text_visible(false)
		
	# hide message box if needed
	if dialog_messagebox_active:
		await _anim_dialog_messagebox_visible(false)
	
	# Hide dialog if needed
	if dialog_maincontrol_active:
		await _anim_dialog_main_visible(false)
	
	MadTalkGlobals.is_during_cinematic = false
	

	# -----------------------------------------------------
	# Stop processing dialog. Next calls will run immediately. 
	# There must be no `yield` statements from now on to the end of the method
	MadTalkGlobals.is_during_dialog = false
	
	# If something is queued, process it before anything else calls this again
	if dialog_queue.size() > 0:
		var dialog_cursor = dialog_queue.pop_front()
		if dialog_cursor:
			start_dialog(dialog_cursor.sheet_name, dialog_cursor.sequence_id)


func run_dialog_sequence(sheet_name: String, sequence_id : int = 0) -> void:
	# Asking to run an invalid dialog fails silently
	if not sheet_name in dialog_data.sheets:
		await get_tree().process_frame
		debug_print("Sheet \"%s\" not found" % sheet_name)
		return
	
	# Make sure we have the node mapped
	if _prepare_sheet_sequence_map(sheet_name, sequence_id) == FAILED:
		await get_tree().process_frame
		debug_print("Mapping sheet \"%s\", sequence %s failed" % [sheet_name, str(sequence_id)])
		return
		
	await run_dialog_item(sheet_name, sequence_id)
	
	emit_signal("dialog_sequence_processed", sheet_name, sequence_id)
	


func run_dialog_item(sheet_name: String = "", sequence_id: int = 0, item_index: int = 0) -> void:

	var sequence_data : DialogNodeData = _retrieve_sequence_data(sheet_name, sequence_id)
	var dialog_item : DialogNodeItemData = _retrieve_item_data(sequence_data, item_index)
	var should_run_next_item := true
	
	var is_last_item: bool = (item_index >= (sequence_data.items.size()-1))
	var should_auto_progress_message: bool = (is_last_item and AutoShowMenuOnLastMessage and (sequence_data.options.size() > 0))
	
	if is_abort_requested:
		emit_signal("dialog_aborted")
	
	elif sequence_data: # Sanity check
		
		if dialog_item:
			# We still have an item to process inside this sequence
			
			match dialog_item.item_type:
				DialogNodeItemData.ItemTypes.Message:
					# dialog_item.message_speaker_id : String
					# dialog_item.message_text       : String
					# message_text can use locale, so it is retrieved via
					#   dialog_item.get_localized_text()
					
					# We show the message here, but we don't hide, since the
					# player might want to re-read the last message when a set
					# of options is presented in the end of the sequence
					
					# Skipping a dialog before a message is shown prevents the
					# messages from showing up. But if this sequence has a menu
					# we have to show the last message, so we still assing all
					# the values, we just don't play the show animations or
					# wait for confirmation
					
					MadTalkGlobals.is_during_cinematic = true
					
					# If text still on screen, hide text
					await _anim_dialog_text_visible(false)
						
					# if speaker has changed, we hide dialog to show again
					if (dialog_item.message_speaker_id != last_speaker_id) or (dialog_item.message_speaker_variant != last_speaker_variant):
						await _anim_dialog_messagebox_visible(false)
						emit_signal("speaker_changed", last_speaker_id, last_speaker_variant, dialog_item.message_speaker_id, dialog_item.message_speaker_variant)
					
					MadTalkGlobals.is_during_cinematic = false
						
					# Modify values
					var speaker_name = character_data[dialog_item.message_speaker_id].name \
						if (dialog_item.message_speaker_id in character_data) \
							else dialog_item.message_speaker_id
					
					if dialog_speakerlabel:
						dialog_speakerlabel.text = speaker_name
					
					var dialog_message_data = msgparser.process(
						TextPrefixForAllMessages + dialog_item.get_localized_text() + TextSuffixForAllMessages, 
						MadTalkGlobals.variables
					)
					var dialog_message_text = dialog_message_data[0]
					var dialog_message_anim_pause_percentages = dialog_message_data[1]
					
					dialog_message_text = dialog_message_text.replace("$time", MadTalkGlobals.gametime["time"])
					dialog_message_text = dialog_message_text.replace("$date_inv", MadTalkGlobals.gametime["date_inv"])
					dialog_message_text = dialog_message_text.replace("$date", MadTalkGlobals.gametime["date"])
					dialog_message_text = dialog_message_text.replace("$weekday", MTDefs.WeekdayNames[MadTalkGlobals.gametime["weekday"]] )
					dialog_message_text = dialog_message_text.replace("$wday", MTDefs.WeekdayNamesShort[MadTalkGlobals.gametime["weekday"]] )
					
					# Should be last replacement, to avoid things in speaker nane to be mistaken for formatting
					dialog_message_text = dialog_message_text.replace("$speaker_id", dialog_item.message_speaker_id )
					dialog_message_text = dialog_message_text.replace("$speaker_name", speaker_name )
					
					if dialog_messagelabel:
						dialog_messagelabel.text = dialog_message_text
							
					if dialog_speakeravatar:
						if (dialog_item.message_speaker_id in character_data):
							# are we using a valid variant?
							var char_variants = character_data[dialog_item.message_speaker_id].variants
							if (dialog_item.message_speaker_variant != "") and (dialog_item.message_speaker_variant in char_variants) \
									and (char_variants[dialog_item.message_speaker_variant] is Texture2D):
								dialog_speakeravatar.texture = char_variants[dialog_item.message_speaker_variant]
							# Otherwise use default avatar
							else:
								dialog_speakeravatar.texture = character_data[dialog_item.message_speaker_id].avatar
						else:
							dialog_speakeravatar.texture = null
					
					
					if not is_skip_requested:
					
						MadTalkGlobals.is_during_cinematic = true
					
						emit_signal("message_text_shown", 
							dialog_item.message_speaker_id,
							dialog_item.message_speaker_variant,
							dialog_message_text,
							dialog_item.message_hide_on_end
						)
					
						# Show main dialog interface if not yet visible
						await _anim_dialog_main_visible(true)
						
						# Show message box if not visible yet
						await _anim_dialog_messagebox_visible(true)
						
						# Request voice clip to be played
						# Signal is emitted even when clip path is blank, so the
						# previous audio can be stopped if this is desired
						emit_signal("voice_clip_requested", dialog_item.message_speaker_id, dialog_item.get_localized_voice_clip())

						MadTalkGlobals.is_during_cinematic = false
						
						var previous_percent_visible = 0.0
						
						# If there are no pauses, we will have
						# dialog_message_anim_pause_percentages = [1.0]

						# If skip was requested after we enter this match case,
						# we just don't wait for user confirmation to dismiss
					
						for percent_visible in dialog_message_anim_pause_percentages: 
							# If skip was requested between pauses, process here
							if is_skip_requested or is_abort_requested:
								break
							
							# Show text
							await _anim_dialog_text_visible(true, 
								[previous_percent_visible, percent_visible]
							) # Handles animation skip internally

							if dialog_messagelabel:
								dialog_messagelabel.visible_ratio = percent_visible
							previous_percent_visible = percent_visible
							
							# Confirmation to dismiss the message
							if (not is_skip_requested) and (not is_abort_requested) and (not should_auto_progress_message):
								await self.dialog_acknowledged
						
						
					if (dialog_item.message_hide_on_end != 0) or is_skip_requested:
						# We hide this message box as explicitly requested
						MadTalkGlobals.is_during_cinematic = true
						await _anim_dialog_text_visible(false)
						await _anim_dialog_messagebox_visible(false)
						MadTalkGlobals.is_during_cinematic = false
					
					# Else: we do not hide the message straight away as next step
					# could be showing options. We hide when we leave the sequence
					last_speaker_id = dialog_item.message_speaker_id
					last_speaker_variant = dialog_item.message_speaker_variant
					last_message_item = dialog_item
					last_message_text = dialog_message_text
				
			
				
				DialogNodeItemData.ItemTypes.Condition:
					# dialog_item.condition_type   : MTDefs.ConditionTypes
					# dialog_item.condition_values : Array
					# dialog_item.connected_to_id  : int = -1
					
					# Test the condition
					var result = await evaluate_condition(dialog_item.condition_type, dialog_item.condition_values)
					
					if not result:
						# Condition failed, we have to branch out of this sequence
						should_run_next_item = false
						
						# If something is connected, we jump
						# If nothing is connected, this simply means aboting
						if dialog_item.connected_to_id > -1:
							await run_dialog_sequence(sheet_name, dialog_item.connected_to_id)
				
				
				
				DialogNodeItemData.ItemTypes.Effect:
					# dialog_item.effect_type   : MTDefs.EffectTypes
					# dialog_item.effect_values : Array
					
					# "Change sheet" effect is an exception and is implemented
					# directly in this block since it is scope-dependant
					if dialog_item.effect_type == MTDefs.EffectTypes.ChangeSheet:
						var new_sheet_name = dialog_item.effect_values[0]
						var new_sequence_id = dialog_item.effect_values[1]
						
						# Jump to sheet if valid, aborting dialog otherwise
						should_run_next_item = false
						if new_sheet_name in dialog_data.sheets:
							await run_dialog_sequence(new_sheet_name, new_sequence_id)
							
					# Animation and custom effects are also exception since 
					# involves pausing the sequence until it finishes
					elif dialog_item.effect_type == MTDefs.EffectTypes.WaitAnim:
						var anim_name = dialog_item.effect_values[0]
						# Animation must exist and not be loop
						if effects_anims and (effects_anims.has_animation(anim_name)) and (
							not effects_anims.get_animation(anim_name).loop
						):
							effects_anims.play(anim_name)
							MadTalkGlobals.is_during_cinematic = true
							await effects_anims.animation_finished
							MadTalkGlobals.is_during_cinematic = false
							
					elif dialog_item.effect_type == MTDefs.EffectTypes.Custom:
						if custom_effect_callable:
							var custom_id = dialog_item.effect_values[0]
							var custom_data_array = MadTalkGlobals.split_string_autodetect_rn(dialog_item.effect_values[1])
							
							#emit_signal("activate_custom_effect", custom_id, custom_data_array)
							await custom_effect_callable.call(custom_id, custom_data_array)
						
					else:
						# All other effects have global scope and are
						# implemented in a separate method
						activate_effect(dialog_item.effect_type, dialog_item.effect_values)

				_:
					debug_print("Invalid item type for item %s in sequence ID %s at sheet \"%s\"" % [item_index, sequence_id, sheet_name])
					

			emit_signal("dialog_item_processed", sheet_name, sequence_id, item_index)

			# We don't check if item_index is the last one, since the first
			# invalid index will be properly handled in following call
			# causing the sequence to be gracefully concluded (see below)
			if should_run_next_item:
				await run_dialog_item(sheet_name, sequence_id, item_index + 1)
		
		
		else: # All items processed
			
			# Running an item_index higher than last valid one means we 
			# finished the item list and have to process the end of sequence
			# This means showing options or routing to the "continue" ID 
			
			# We process menu options even if dialog_buttons_container is not
			# assigned, as the menu might be handled externally via signals
			
			# Even if we have options, some of them can be conditional, and
			# it might be the case all of them are and no item is left to
			# be shown at the menu. So we have to buffer a list
			var options_to_show = []
			menu_connected_ids.clear()
			
			for option_item in sequence_data.options:
				if (not option_item.is_conditional) or _check_option_condition(
					option_item.condition_variable, 
					option_item.condition_operator, 
					option_item.condition_value
				):
					options_to_show.append(option_item)
					menu_connected_ids.append(option_item.connected_to_id)
			
			# Process options and build menu only with remaining items
			if options_to_show.size() > 0:
				# When there are menu options, "continue" is not used
				
				# If we skipped dialog, there are no visible messages. We show
				# last one back
				if is_skip_requested and (last_message_item.message_hide_on_end == 0):
					
						MadTalkGlobals.is_during_cinematic = true
					
						emit_signal("message_text_shown", 
							last_message_item.message_speaker_id,
							last_message_item.message_speaker_variant,
							last_message_text,
							last_message_item.message_hide_on_end
						)
					
						# Show main dialog interface if not yet visible
						await _anim_dialog_main_visible(true)
						
						# Show message box if not visible yet
						await _anim_dialog_messagebox_visible(true)
						
						# We do not play voice
						
						MadTalkGlobals.is_during_cinematic = false
						
						# Show text
						await _anim_dialog_text_visible(true, [0, 1], true) # skips to end
					
						
				MadTalkGlobals.is_during_cinematic = true
				
				# Internal menu logic (via dialog_buttons_container)
				if dialog_buttons_container:
					# Make sure menu is not visible
					if dialog_menu_active:
						await _anim_dialog_menu_visible(false)
					# Regenerate buttons
					var __= _assemble_menu(options_to_show)
					# Show menu
					await _anim_dialog_menu_visible(true)
				
				else:
					emit_signal("external_menu_requested", options_to_show)
				
				MadTalkGlobals.is_during_cinematic = false
				
				if dialog_buttons_container:
					# There is always at least one optiong there otherwise we
					# would not be into this `if`
					dialog_buttons_container.get_child(0).grab_focus()
				
				# Wait for an option
				# Selecting an option is mandatory and dialog halts until then
				var option_id = await self.menu_option_activated
				
				# Hide menu
				if dialog_buttons_container:
					MadTalkGlobals.is_during_cinematic = true
					await _anim_dialog_menu_visible(false)
					MadTalkGlobals.is_during_cinematic = false
				
				if option_id > -1:
					# jumping to another sequence might also be same speaker 
					# so we don't hide anything yet
					await run_dialog_sequence(sheet_name, option_id)
				else:
					last_speaker_id = ""
					last_speaker_variant = ""
					emit_signal("dialog_finished", sheet_name, sequence_id)


			elif sequence_data.continue_sequence_id > -1:
				# "continue" ID might also be same speaker so we don't hide anything yet
				await run_dialog_sequence(sheet_name, sequence_data.continue_sequence_id)
				
			else:
				last_speaker_id = ""
				last_speaker_variant = ""
				emit_signal("dialog_finished", sheet_name, sequence_id)

	else:
		debug_print("Invalid sequence \"%s\" in sheet \"%s\"" % [sequence_id, sheet_name])
	
	
	
	
func evaluate_condition(condition_type, condition_values):
	# Returns true if condition is met, false otherwise
	# May or may not morph into coroutine, caller must check with:
	#     if result is GDScriptFunctionState:
	#	      result = yield(result, "completed")
	
	match condition_type:
		MTDefs.ConditionTypes.Random:
			var random_value = rng.randf_range(0.0, 100.0)
			return (random_value < condition_values[0])
		
		MTDefs.ConditionTypes.VarBool:
			var var_value = bool_as_int(MadTalkGlobals.get_variable(condition_values[0], 0))
			var expected_value = bool_as_int(condition_values[1])
			return (var_value == expected_value)
			
		MTDefs.ConditionTypes.VarAtLeast:
			var var_value = float(MadTalkGlobals.get_variable(condition_values[0], 0.0))
			return (var_value >= float(condition_values[1]))

		MTDefs.ConditionTypes.VarUnder:
			var var_value = float(MadTalkGlobals.get_variable(condition_values[0], 0.0))
			return (var_value < float(condition_values[1]))

		MTDefs.ConditionTypes.VarString:
			var var_value = str(MadTalkGlobals.get_variable(condition_values[0], ""))
			return (var_value == str(condition_values[1]))
		
		MTDefs.ConditionTypes.Time:
			var min_time = MadTalkGlobals.split_time(condition_values[0])
			var target_min_time_float = MadTalkGlobals.time_to_float(min_time[0], min_time[1])

			var max_time = MadTalkGlobals.split_time(condition_values[1])
			var target_max_time_float = MadTalkGlobals.time_to_float(max_time[0], max_time[1])

			var curr_time_float = MadTalkGlobals.time_to_float(
				MadTalkGlobals.gametime["hour"], 
				MadTalkGlobals.gametime["minute"]
			)
			
			# Normal range - e.g. 6:00-18:00
			if target_min_time_float < target_max_time_float:
				return (curr_time_float >= target_min_time_float) and (curr_time_float <= target_max_time_float)
			# Inverted range - e.g. 18:00-6:00
			else:
				return (curr_time_float >= target_min_time_float) or (curr_time_float <= target_max_time_float)
		
		MTDefs.ConditionTypes.DayOfWeek:
			var target_min_day = condition_values[0]
			var target_max_day = condition_values[1]
			var curr_day = MadTalkGlobals.gametime["weekday"]

			# Normal range - e.g. Mon-Fri
			if target_min_day < target_max_day:
				return (curr_day >= target_min_day) and (curr_day <= target_max_day)
			# Inverted range - e.g. Sat-Sun
			else:
				return (curr_day >= target_min_day) or (curr_day <= target_max_day)
		
		MTDefs.ConditionTypes.DayOfMonth:
			var target_min_day = condition_values[0]
			var target_max_day = condition_values[1]
			var curr_day = MadTalkGlobals.gametime["day"]

			# Normal range - e.g. 14 - 21
			if target_min_day < target_max_day:
				return (curr_day >= target_min_day) and (curr_day <= target_max_day)
			# Inverted range - e.g. 25 - 14
			else:
				return (curr_day >= target_min_day) or (curr_day <= target_max_day)

		MTDefs.ConditionTypes.Date:
			var target_min_day_month = MadTalkGlobals.split_date(condition_values[0])
			var target_min_intdate = MadTalkGlobals.date_to_int(target_min_day_month[0], target_min_day_month[1], 1)
			
			var target_max_day_month = MadTalkGlobals.split_date(condition_values[1])
			var target_max_intdate = MadTalkGlobals.date_to_int(target_max_day_month[0], target_max_day_month[1], 1)

			var curr_intdate = MadTalkGlobals.date_to_int(MadTalkGlobals.gametime["day"], MadTalkGlobals.gametime["month"], 1)

			# Normal range - e.g. 15/02 - 25/03
			if target_min_intdate < target_max_intdate:
				return (curr_intdate >= target_min_intdate) and (curr_intdate <= target_max_intdate)
			# Inverted range - e.g. 25/12 - 28/02
			else:
				return (curr_intdate >= target_min_intdate) or (curr_intdate <= target_max_intdate)

		MTDefs.ConditionTypes.ElapsedFromVar:
			var delta_time = float(condition_values[0])
			var target_time = MadTalkGlobals.get_variable(condition_values[1], 0)
			var delta_currently_elapsed = MadTalkGlobals.time - target_time
			
			return (delta_currently_elapsed >= delta_time)

		MTDefs.ConditionTypes.Custom:
			if (not custom_condition_callable):
				return false
			
			var custom_id = condition_values[0]
			var custom_data_array = MadTalkGlobals.split_string_autodetect_rn(condition_values[1])
					
			var result = await custom_condition_callable.call(custom_id, custom_data_array)
			
			if (result is int) or (result is float):
				return (result != 0)
				
			elif result is bool:
				return result
				
			else:
				return false

		_:
			return false
	

func activate_effect(effect_type, effect_values):
	match effect_type:
		MTDefs.EffectTypes.ChangeSheet:
			# This effect is an exception and is not implemented here
			pass
		
		MTDefs.EffectTypes.SetVariable:
			MadTalkGlobals.set_variable(effect_values[0], float(effect_values[1]))
		
		MTDefs.EffectTypes.AddVariable:
			var old_value = float(MadTalkGlobals.get_variable(effect_values[0]))
			MadTalkGlobals.set_variable(effect_values[0], old_value + float(effect_values[1]))
		
		MTDefs.EffectTypes.RandomizeVariable:
			var range_min = float(effect_values[1])
			var range_max = float(effect_values[2])
			MadTalkGlobals.set_variable(effect_values[0], 
				rng.randf_range(range_min, range_max)
			)
		
		MTDefs.EffectTypes.StampTime:
			MadTalkGlobals.set_variable(effect_values[0], MadTalkGlobals.time)
			
		MTDefs.EffectTypes.SpendMinutes:
			MadTalkGlobals.time += int(round(float(effect_values[0]) * 60))  # value * 60s
			MadTalkGlobals.update_gametime_dict()
			emit_signal("time_updated", MadTalkGlobals.gametime)

		MTDefs.EffectTypes.SpendDays:
			MadTalkGlobals.time += int(round(float(effect_values[0]) * 24*60*60)) # value * 24h * 60m * 60s
			MadTalkGlobals.update_gametime_dict()
			emit_signal("time_updated", MadTalkGlobals.gametime)

		MTDefs.EffectTypes.SkipToTime:
			MadTalkGlobals.time = MadTalkGlobals.next_time_at_time(effect_values[0])
			MadTalkGlobals.update_gametime_dict()
			emit_signal("time_updated", MadTalkGlobals.gametime)

		MTDefs.EffectTypes.SkipToWeekDay:
			MadTalkGlobals.time = MadTalkGlobals.next_time_at_weekday(effect_values[0])
			MadTalkGlobals.update_gametime_dict()
			emit_signal("time_updated", MadTalkGlobals.gametime)
		
		MTDefs.EffectTypes.Custom:
			# This effect is an exception and is not implemented here
			pass


func dialog_acknowledge():
	# Called externally by UI to confirm a dialog message and progress dialog
	if dialog_on_text_progress:
		# This happened during text progression
		if AnimateText:
			if animated_text_tween:
				animated_text_tween.kill()
			#dialog_messagelabel.percent_visible = 1.0 # moved to run_dialog_item()
		
		else:
			if (dialog_anims.current_animation == TransitionAnimationName_TextShow) and dialog_anims.is_playing():
				dialog_anims.advance(dialog_anims.current_animation_length - dialog_anims.current_animation_position)
		
		emit_signal("text_display_completed")
	elif not MadTalkGlobals.is_during_cinematic:
		emit_signal("dialog_acknowledged")
		
func dialog_abort():
	is_abort_requested = true
	dialog_acknowledge()
	
func dialog_skip():
	is_skip_requested = true
	dialog_acknowledge()
		
func change_scene_to_file(scene_path: String) -> void:
	# Convenience method giving access to get_tree().change_scene()
	# as a node method in the scene tree
	# This exists so you can connect signals from animation tracks in
	# AnimationPlayer's directly to cause scene changes
	var __= get_tree().change_scene_to_file(scene_path)

func select_menu_option(index: int):
	if index < menu_connected_ids.size():
		emit_signal("menu_option_activated", menu_connected_ids[index])


func _on_animation_finished(anim_name):
	if anim_name == TransitionAnimationName_TextShow:
		emit_signal("text_display_completed")
		
func _on_animated_text_tween_completed():
	emit_signal("text_display_completed")
	

func _on_menu_button_pressed(id):
	emit_signal("menu_option_activated", id)

func get_sheet_names():
	return dialog_data.sheets.keys()
